Skip to content

canvas: a chat panel on the left, for Claude Code and Codex - #96

Merged
Jing-yilin merged 62 commits into
mainfrom
canvas-agent-chat
Sep 18, 2026
Merged

Jing-yilin merged 62 commits into
mainfrom
canvas-agent-chat

Conversation

@Jing-yilin

Copy link
Copy Markdown
Contributor

What this changes

The canvas grows a chat panel: you talk to Claude Code or Codex in the page, in
your own project, and watch it work. It runs from the dev server the canvas
already has, so there is no second backend.

  • The panel. A flex sibling on the left, not an overlay, so the canvas
    keeps its full width. Two-step transport (POST /__sp/agent/run → 202
    {runId}, then SSE from ?after=N), which is what makes a board rewrite —
    which reloads the whole page — pick its own run back up instead of losing it.
  • Two agents, one table. ChatEvent was already agent-neutral, so the
    second CLI is a data literal plus a parser: agents.ts holds argv, stdin and
    the parser per agent, and vite.config.ts spawns from the table. The mark in
    the header is the switch; agents are found with a PATH probe and the ones you
    do not have say how to get them. Codex's body arrives in one piece rather
    than streamed — that is codex exec --json, not us, and the parser's header
    says so.
  • Markdown. remendmarkedDOMPurify, 27 KB gzipped with the
    sanitizer. Streamdown was measured (159 KB gzipped, ~110 packages) and
    rejected; remend alone is the piece that stops mid-token flicker.
  • Model, effort, and cost. A strip under the composer. Codex's model list
    is read from the cache its own picker draws, so a model you gain by updating
    the CLI is simply there; effort levels come per model because they differ.
    Default sends no flag, leaving your config.toml and Claude's settings in
    charge. The number on the right is the tokens that message used against the
    model's window — that message, not the conversation, since each one is its
    own process.
  • The header names the conversation from the agent's own title, lists the
    server's recent runs, and folds to a rail.

Decisions are in docs/2026-09-17-canvas-chat-panel.md, including what was
deliberately left out: no resume for either agent, no codex app-server
transport, no settings page.

Needs the toolkit reinstalled, not only the plugin updated — sp-canvas 1.2.0
predates PROTOTYPING_PROJECT_DIR and every message answers 503 without it.

Checklist

  • No ref-*.html, assets/refs/ or other third-party captures are in this PR.
  • If a canvas folder changed: gen.py was edited and re-run, and the NN-*.html boards were not hand-edited. (no canvas folder changed)
  • If a canvas folder changed: layout.json, probes.json, crops.json and assets.json are committed alongside the boards. (n/a)
  • Every new canvas folder has a README.md and no folder has its own .gitignore. (no new folder)
  • If a user would see this change: it has a line under ## Unreleased in RELEASE-NOTES.md.
  • If canvas/ changed: bun run test (126 pass) and bun run build pass in canvas/. Note the suite is vitest run — a bare bun test bypasses the Vite plugins and fails on virtual:canvases on main too.

🤖 Generated with Claude Code

Jing-yilin and others added 16 commits September 17, 2026 12:45
… run

Three routes under /__sp/agent/run: POST starts one `claude -p` in the
user's project and answers 202 with a run id; GET :id/events?after=N is a
server-sent event stream of everything after N, replayed from what the
server kept and then live; POST :id/cancel stops it. Two steps rather than
one streaming response because a board the agent writes reloads the page
through the watcher, and a stream bound to the starting fetch would die
exactly when the run succeeds.

claudeStream.ts reduces the CLI's stream-json frames to text, a thinking
marker, tool calls with their results, and the end; its tests run against
recordings of Claude Code 2.1.274 with the flags the server spawns.
agentRun.ts is the per-run event log and its attach/replay, node-free so
vitest covers it.

The agent's cwd is the user's project, which the server learns from
PROTOTYPING_PROJECT_DIR; sp-canvas start sets it to the directory it is
started from, and without it the routes answer 503 by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ChatPanel.tsx is the inspector's mirror as a flex sibling before the
editor: 360px, so tldraw's viewport shrinks by it and every screen-space
calculation stays right. Floating was rejected — tldraw 5 has a scalar
inset and symmetric padding, no asymmetric expression — and the comment
in index.css now says so.

The panel keeps its run ids in sessionStorage and reads each run again
from event zero after the reload a board write causes; chatTransport.ts
holds the SSE decoding, the reconnect-with-cursor loop and the fold from
events into a turn, with tests. One run at a time, Send becomes Stop
while it goes, and the empty state says permission prompts are off.
Dev server only: App.tsx mounts it under import.meta.env.DEV.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/2026-09-17-canvas-chat-panel.md keeps why the panel squeezes rather
than floats, why the transport is two steps with a cursor, why permissions
are off and where that is said, and what was left out. The prototype-canvas
skill gets a section on the panel, since the agent it runs is pointed at
that skill; RELEASE-NOTES gets its Unreleased line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The system prompt asks Claude Code to open its reply with the run's title as
<sp-title>…</sp-title>, and titleFilter in claudeStream.ts lifts it out on
the server, before anything is emitted, into a title event: the page never
sees the marker as text. The marker arrives split across deltas, so text is
held only while it could still be the marker, and the blank lines the model
puts after it go with it whichever delta they come in — the recorded fixture
had them in the closing delta, a live run had them in the next one.

Every run now opens with a start event carrying the prompt, its first line as
the title until the model gives one, and the time, so a replay from zero
rebuilds the whole turn. GET /__sp/agent/runs lists the runs the server still
holds, newest first, read off those same events by runSummary; the middleware
moves up to /__sp/agent so the one 503 guard covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The header shows the conversation's title behind Claude's mark — the model's
own once it has given one, the prompt's first line until then. A clock opens
the server's recent runs as a native popover, placed under the header by the
panel's fixed geometry; picking one replays it. A panel icon folds the panel
to a 36px rail that is the mark itself, which opens it again; nothing
unmounts, so a run keeps being followed while the panel is closed, and the
state is in localStorage so a closed panel stays closed across the reload a
board write causes. sessionStorage keeps only run ids now, since the start
event carries the prompt.

ClaudeMark.tsx is one path from lobehub/icons' Claude.Color (MIT), drawn inline
like every icon here rather than pulled in as a package that is nine megabytes
and an Ant Design stack. One agent, one mark, no registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…talling

The decision doc gains the title marker and why it never reaches the page,
the history that lives in the server's memory and dies with it by design,
collapsing as a width change, and the one mark with no registry behind it.
The release notes say the panel's header changed and that sp-canvas 1.2.0
predates PROTOTYPING_PROJECT_DIR, so the toolkit must be reinstalled or every
message answers 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude writes markdown — bold, lists, fences, GFM tables with a <br> inside
a cell — and the panel drew it as source. renderMarkdown in markdown.ts is
now the one path from model text to the DOM: remend closes what a delta cut
open, so a streamed **bold is bold from the first paint rather than
asterisks until the next delta; marked parses; DOMPurify sanitizes, because
marked passes <script> through untouched. Three small libraries, 27 KB
gzipped together, where Streamdown — aimed at exactly this — is 159 KB and a
hundred packages behind one export. No highlighter: a fence is escaped text.

Wide tables and fences scroll sideways inside their block rather than
widening the 360px panel. A table exists only once its delimiter row has
streamed in, and becomes one in place; nothing can know sooner.

jsdom is a dev dependency for the sanitizer test alone. happy-dom was tried
first and dropped: DOMPurify strips <table> and empties an <img> under it,
which a browser does not, and a sanitizer test on a DOM that misbehaves holds
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The decision doc gets the measurements behind the choice, the two renderers
Open Design already has and why neither is a straight copy, the sanitizer
rule, and the two things streaming does that nothing fixes. The release notes
say replies now render as markdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel's header carried the open canvas's slug, a few hundred pixels left
of the canvas's own header carrying the same board's name. One of the two had
to go and it is not the one that owns it. The slug still travels with every
message; only the chip is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Undo, redo, delete and duplicate came from tldraw's QuickActions, and the
overflow of aligns, distributes and reorders from its ActionsMenu. The shapes
on this canvas are boards written from files, so six buttons for nudging them
sat between the page's name and the two destinations that are what the bar is
for. QuickActions now renders the comment tool alone — the one mark someone
makes on a canvas that is read rather than drawn on — and the actions menu
renders the app's own buttons without the default menu behind them.

None of the actions are gone, only their buttons: the keyboard shortcuts and
the right-click menu are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
codexEventsFromLine turns what `codex exec --json` writes into the same seven
ChatEvents claudeStream.ts produces, so nothing past the parser has to know
which CLI it is reading: a command as it starts and when it exits, a file
change the same way, a reasoning summary as the thinking marker, the message
as text, and the turn's end. The thread id, turn.started, token usage and the
error items that are only warnings about the configured model are dropped.

Nothing streams on this wire. Codex has suppressed its message deltas since
rust-v0.8.0, so the reply arrives whole in one item.completed after every
tool line; the header comment says so, so nobody goes looking for why a Codex
turn seems to hang before its text. Its other wire, app-server, does stream,
and is a JSON-RPC session Open Design carries a second transport for; this
panel does not.

A failed turn says so twice, as a bare error and then turn.failed with the
same text, and only the second ends the run, since agentRun.ts refuses a
second end. The message is the server's wording verbatim — on this machine
the configured model is one the installed CLI is too old for, and that
sentence is the whole diagnosis. The two fixtures are recordings of codex-cli
0.146.0, paths renamed; file_change has no recording here yet and takes its
shape from Open Design's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
agents.ts holds one object literal per CLI — what to spawn, how the preamble
and the message reach it, and which parser reads it back — and the dev server
looks the spawn up there by the id the panel sends, defaulting to claude. The
run, its events and the stream to the page are the same past that point. The
shape is Open Design's RuntimeAgentDef, which carries twenty-eight CLIs over
one engine; this carries the fields the two here genuinely differ in and is
not a registry. A third agent is a third literal.

Claude Code takes the preamble as --append-system-prompt and the message as
one stream-json line. Codex has no system-prompt flag, so the preamble goes
ahead of the message on stdin as plain text; it runs in its own
workspace-write sandbox with the network on, and is told the boards folder
with --add-dir, since sp-canvas --canvases can put that outside the project
and the sandbox writes the working directory alone. It is also asked for a
reasoning summary, without which the stream carries no reasoning item at all
on a turn that reasoned; the summary is what becomes the thinking marker.

GET /__sp/agent/agents says which agents are installed, by `bin --version`
once each for the server's lifetime, ahead of the project check since PATH
does not depend on it. The start event and RunSummary name the agent, so a
run replayed from zero and the history list both know whose mark to draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mark on the chat panel's header was Claude's, and said only who the
panel talked to. Now it is a button: it opens a menu of the agents the
server found on PATH, the chosen one's mark takes its place, and the
choice is kept in localStorage and sent with every message. An agent
the server could not find is in the menu greyed, with the sentence the
run would have failed with under its name, so the way to install it is
where the user looks for it.

Which agent runs a turn is part of the turn: the start event carries
it, chatTransport keeps it on the Turn, and each turn and each history
row shows the mark of the agent that ran it, since one conversation can
switch between messages and the header only says where the next one
goes. The composer's placeholder and the title before the model gives
one name the chosen agent.

Codex's mark is lobehub's, inlined next to Claude's as ClaudeMark.tsx
said a second agent would be; it is monochrome, so it takes the text
colour where Claude's keeps its terracotta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design note gets the section the mark's own paragraph promised: why
the spawn moved into a table of two literals rather than a registry,
where Claude Code and Codex genuinely differ — the preamble on stdin,
--add-dir for the boards, the reasoning summary that has to be asked
for — why a Codex turn shows its commands one by one and its reply all
at once, why a failed turn ends once with the server's own sentence,
and the gpt-6-astra refusal that sentence will be on this machine
until the CLI is updated. The left-out list now says resuming and
model choice are left out for both.

The release notes tell the user the mark is a switch and what to
expect of Codex; the skill stops naming only claude -p.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran both through the panel and fixed what the running showed.

Codex's failure was a line of JSON: it puts an API refusal on the wire as the
whole response body inside a string, so "the 'gpt-6-astra' model requires a
newer version of Codex" arrived wrapped in an envelope nobody needs to read.
One layer comes off, and the sentence shows.

Two things the parser's notes claimed are not what codex-cli 0.146.0 sends. A
turn does not save its text for the end: the recordings open with a sentence
about what it is off to do, then the tool lines, then the answer. And the
`file_change` shape was Open Design's recording, with a note saying no turn
here had written a file; one has now, spawned the way agents.ts spawns it, and
it is the fixture the test reads instead of the hand-written lines.

The title filter gave up too early. A turn with work to do opens by saying
what it is about to do, runs a tool, and titles the reply after it — and the
filter, having released the first sentence, left the marker in the second,
where the sanitizer dropped the tags and the model's title sat in the middle
of the text as a stray line while the header showed the prompt. A tool call
re-arms it, until a title has been lifted.

A run the server has forgotten replayed as an error under an empty bubble, and
came back again on the next reload, and the one after. There is no
conversation left to put an error under, so the turn goes too.

The agent menu was 280px for two rows that need 150; the width is the ceiling
now, for the row of an agent that is missing and says how to get it, whose
mark also dims with its text rather than staying in its brand colour beside
it. And a history row keeps its timestamp on one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A strip under the composer: which model, which effort, and the tokens the last
message took. Both lists are data on the AgentDef, beside the argv each agent
takes. Claude's is written down, since Claude Code publishes no list. Codex
keeps its own at ~/.codex/models_cache.json — the server-sent presets its own
picker draws, with display names, context windows and the reasoning levels each
model actually takes, which differ per model — so AgentDef carries a modelsFile
of a home-relative path and a pure read(json), and vite.config.ts does the
readFileSync beside the PATH probe. agents.ts stays free of node.

Default is the first row of both pickers and sends no flag, so config.toml and
Claude's own settings keep deciding until you pick. A choice is per agent and
kept; a stale one is not sent; the server checks both against the same list,
since both reach a command line. Effort is not symmetric: claude takes --effort
and refuses an unknown level at startup, codex takes -c model_reasoning_effort
and finds out mid-turn, which is why the levels come per model.

The count rides in on a new usage event. Claude's result frame sums input, both
cache figures and output, and names the window in modelUsage — largest of the
models listed, as a turn with a sub-agent reports the sub-agent's first. Codex
reports usage and no window at all; the server stamps it from the same cache.
The number is one message, not the conversation: each is still its own process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 17, 2026

Copy link
Copy Markdown

Deploying super-prototyping with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7b05506
Status: ✅  Deploy successful!
Preview URL: https://7e138edb.super-prototyping.pages.dev
Branch Preview URL: https://canvas-agent-chat.super-prototyping.pages.dev

View logs

The header mark is the agent the next message goes to, so a Codex conversation
opened from history under a Claude mark was the header saying something untrue
about what was on screen — and the model and effort under the composer were the
other agent's as well. The history row already carries who ran it; picking one
now selects that agent, the same way the menu does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ecd428bc15

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/src/ChatPanel.tsx
Comment on lines +217 to +220
abort.current.abort();
abort.current = new AbortController();
setTurns([turnFor(runId)]);
follow(runId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the active run when selecting history

When an agent is still running and the user selects a completed history row, this aborts only the SSE follower and replaces the active turn without cancelling its child process. Because the selected turn soon has an end event, the composer permits another run while the abandoned agent is still editing the same project, allowing concurrent agents to overwrite or conflict with each other's changes. Keep the active run tracked, prevent history switching while it runs, or cancel it before replacing the turn.

Useful? React with 👍 / 👎.

Comment on lines +124 to +128
const used =
(u.input_tokens ?? 0) +
(u.cache_creation_input_tokens ?? 0) +
(u.cache_read_input_tokens ?? 0) +
(u.output_tokens ?? 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not divide cumulative turn usage by one context window

For a request that invokes tools or sub-agents, Claude's result-frame usage is cumulative across multiple model invocations, as the accompanying comment also acknowledges. Summing all of those inputs and outputs and pairing the result with one model's context-window size does not measure how full any individual context was and can produce an exaggerated or even greater-than-100% readout. Either derive the maximum per-invocation occupancy or present this value as aggregate token usage without a context-window denominator.

Useful? React with 👍 / 👎.

Jing-yilin and others added 2 commits September 17, 2026 14:53
tldraw's navigation panel sits in the bottom-left corner, which is where the
chat panel's composer now ends, so it was a widget in the way of the one place
you type. Nothing goes with it: zooming was already the trackpad, ⌘+ and ⌘-,
and ⇧1 to fit, and the minimap of a page of boards shows boards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing "/" now lists what the agent can run. The commands already ran — claude
-p takes one as the message text the same way the terminal does, verified
through the panel's own stdin path, skills included — so what was missing was
knowing the names.

The list is Claude Code's own: its init frame names every command available in
that project, the plugins' and the skills' among them, namespaced as it
namespaces them. The dev server keeps what the frame says, so the panel never
discovers commands a second way and nothing goes stale. Nothing is spawned to
ask, either: the frame needs a message to work on, and a run with an empty one
still bills for the system prompt, $0.017 measured — the palette is empty until
the session's first answer instead.

Codex defines none of this, because codex exec hands /foo to the model as the
five characters it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf07cddb72

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/vite.config.ts Outdated
Comment on lines +814 to +819
if (req.method === "POST" && url.pathname === "/run") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
const { message, canvas, agent = "claude", model = "", effort = "" } = JSON.parse(body || "{}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject cross-origin agent launches

When the canvas is running on its default loopback port, any website visited by the user can issue a no-cors POST with a text/plain JSON body to this endpoint; because the handler validates neither Origin nor content type, it will parse that request and launch Claude with bypassPermissions in the user's project. Require a same-origin check or an unguessable per-server token before accepting agent-control requests.

Useful? React with 👍 / 👎.

Comment thread canvas/vite.config.ts Outdated
Comment on lines +846 to +851
const run = Object.assign(newRun(randomUUID()), {
child: spawn(def.bin, def.args({ preamble, boards: canvasesDir, model, effort }), {
cwd: project,
env: process.env,
}),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize agent runs at the server boundary

Even if history switching is disabled, opening the canvas in a second tab or sending a second API request still reaches this unconditional spawn while another run is active, because running is only client-local React state. Both agents can then edit the same project concurrently and overwrite or conflict with each other's work, so the server should reject or queue a new run while any retained run is unfinished.

Useful? React with 👍 / 👎.

Jing-yilin and others added 4 commits September 17, 2026 15:19
Escape closes the palette for the word being typed, and the composer's change
handler forgets that as soon as the draft stops being a slash word. Sending
does not go through that handler — it clears the draft directly — so a word
that was escaped and then sent left the suppression latched on, and every
later slash word opened nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The arrow keys already moved the selection; nothing on screen moved with it.
The selected row was painted in the hover grey, which is a 5% step on white,
and :hover paints that same grey on whatever row the pointer is resting over —
so two rows looked alike and neither looked chosen. The keyboard selection now
takes the accent, and outranks :hover so the pointer cannot impersonate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The palette was truncated to eight rows, so typing "/" on an install with 132
commands showed the alphabetical head of the list and silently hid the rest —
including, for a narrow filter, the one being looked for. The list now holds
all of them and scrolls, and the row the arrow keys land on scrolls itself
into view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A plus beside the history button clears the log so the next message has
nothing above it. The runs themselves were already independent of each other,
so this is about the view rather than the agent: a run still going keeps going
and stays in the history list, since stopping one is what the Stop button
under the composer is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e76f117f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/vite.config.ts Outdated
Comment on lines +846 to +851
const run = Object.assign(newRun(randomUUID()), {
child: spawn(def.bin, def.args({ preamble, boards: canvasesDir, model, effort }), {
cwd: project,
env: process.env,
}),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Terminate agent children when the dev server closes

When a canvas running via the non-tmux background path is stopped while an agent is active, sp-canvas stop sends SIGTERM only to the Vite PID, while this independently spawned child has no server-shutdown cleanup. The agent can therefore continue modifying the project after the UI and in-memory run registry have disappeared; register lifecycle cleanup that terminates every active agent process (and its process tree) when the server closes.

Useful? React with 👍 / 👎.

Codex runs no slash commands: `codex debug prompt-input` — the prompt the CLI
would send, composed locally without spending a turn — shows "/foo bar"
arriving at the model as those characters, whether or not a custom prompt of
that name exists, since the TUI is what expands those. Listing its TUI
commands would have offered things that do nothing here.

What a slash means to codex is a skill, and skills do reach `exec`: the same
probe carries a skills block naming every one, across the personal directory,
the plugin caches and this project's own. So the table gains a second way of
answering the question — `commands` reads a line the agent writes anyway,
`commandsProbe` asks an agent that writes no such line — and codex's palette
is that block, read once per server lifetime, three seconds and no network.

The two are not the same mechanism and the notes say so: Claude Code executes
its own command, codex is told what its skills are and decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b59cc3af30

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread canvas/src/ChatPanel.tsx Outdated
Comment on lines +216 to +217
const { runId } = await res.json();
setTurns((ts) => [...ts, { ...turnFor(runId), prompt: message, agent }]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the run ID before relying on React state

If the canvas reloads after the POST creates the process but before the turns effect writes RUNS_KEY—for example, a startup hook or another process rewrites a board while this request is pending—the new run ID exists only in the interrupted page. After remount, the panel does not follow or show the still-running agent and appears ready for another message. Persist the ID synchronously when it is received, or reconcile active server runs on mount, rather than waiting for the state-driven effect.

Useful? React with 👍 / 👎.

A dev server listens on a port every page in the browser can reach, and CORS
withholds a cross-origin reply, not the request: a site the user visits while
the canvas is open could POST to /__sp/agent/run and start an agent holding
bypassPermissions in their project. The other /__sp endpoints write too — a
board's status, a comment, a cloned canvas.

The browser already says where a request came from, and a page cannot argue
with it: Sec-Fetch-* are forbidden header names. One check in front of all of
them, and absent stays allowed, because absent means the caller was not a
browser and curl was never the attack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jing-yilin and others added 29 commits September 18, 2026 03:52
Comment, clone and force refresh come off the bar. All three already pointed
at the right button: each acts on what is under the cursor or on the page it
is on, which is what a right-click has picked out before the menu opens. The
context menu had two of them already and gains the clone, so nothing lost a
way in, and `QuickActions` goes to `null` with the comment button that was the
last thing left in it.

What the bar keeps is where a board goes next — Export to Figma, Brand kit —
and the switch for the chat panel, which takes the slot tldraw's main menu
used to have. That slot is the leftmost thing in the window, against the left
edge, which is where the switch for the panel on that edge belongs: in the
panel's own header it disappeared along with the panel and needed a second
control to undo it. The state moves up to App with it, since the button and
the panel are siblings now rather than parent and child, and it is remembered
across reloads — it is a preference about this window, not about a board.

Collapsed is `display: none` and not a rail: the button that brings it back is
in the canvas's corner, so a rail would be a second one. Hidden rather than
unmounted, so the panel keeps following whatever is running and comes back to
it mid-stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CTAs were the last hand-built controls in the app: two pills with their
own stylesheet, their own blue and dark gradients and a gold star. They are
the shadcn Button now, over the scale, which is what proves the system works
end to end — the same two elements come out white on the canvas and black on
a brand page with nothing here to keep in step.

Both take the primary variant. Geist's pairing would make the second one
secondary, but these are two asks rather than an ask and an aside, and a
hairline chip beside a solid one reads as the lesser of them.

The SnapAction mark becomes a mask rather than an `<img>`: the file is a fixed
near-white and the ink it sits in is black. Masked, it is whatever the
button's ink is.

canvasCta.css survives holding the shimmer and nothing else. It stays a file
rather than a block in index.css because the pair is also in the brand pages'
topbar, another document with another stylesheet, and an import beside the
component travels with it. The band is a `light-dark()` pair for the same
reason the buttons are one component, and its ends are `rgb(255 255 255 / 0)`
rather than `transparent`, which is transparent *black*: interpolated towards
it the band dims at its own edges, and a grey fringe sweeping across a white
button is the one thing a shimmer must not leave behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…user

The decision note gains the four entries the last commits are: the icon set
instead of a transcription of it and what it costs, the top bar down to a
switch and two destinations, the one icon button and the one scrollbar, and
the shimmer that stays a file of its own.

The release notes say the same things in the order a user meets them: where
the buttons went, where the switch is now, and that the scrollbars are the
app's rather than the platform's — everywhere but inside a board, which keeps
its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran the unslop skill from cursor/plugins over the prose this branch adds:
docs/2026-09-17-canvas-geist.md, the entries this branch appends to
RELEASE-NOTES.md, the page-ground bullet in the two skill files, and the
comments in the twelve source files it touches.

Most of the work was three rules. Em dashes are gone, about seventy of them,
each replaced by a period, a comma, or the word the dash stood in for. Colons
used as sentence glue became sentences. Metaphor gave way to the literal
phrase, so "holds a black menu off a black canvas" now reads "separates a
black menu from a black canvas".

One number was wrong and is fixed. The geist-icons chunk costs 433 kB gzip,
measured from dist/assets/geistIcons-*.js, not the 167 kB an earlier isolated
rolldown test suggested.

Nothing outside a comment or a Markdown paragraph changed. Every token name,
path, measurement and stated trade-off in the design doc survives, and the one
em dash left in the branch is in JSX the app renders, so it is a UI string
rather than prose. The visual claims keep the hedging they were written with,
because none of this branch has been checked in a browser yet.

Green: oxlint clean, tsc -b 0, 17 files and 129 tests, vite build in 5.36s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A message can carry screenshots now, numbered so the sentence can refer to
them — the layout from #1, the copy from #4 — and the number the composer
draws on the tile is the number the agent is handed beside the picture. Paste,
drop, or the button in the row under the box, where Claude Code keeps it.

The other direction came almost free. Whatever a tool hands the agent as an
image arrives on the frame that carries the tool's result, and refkit already
tells the agent to read back what it drew, so a clone's working images — the
grid over the reference, the crops — are drawn under the call that produced
them. Nothing watches the project directory, and nothing had to be taught
which tools draw.

Neither direction puts bytes in the event buffer: a written board reloads the
page and the panel rebuilds every turn from event zero, so what the stream
carries is a number and the picture is a request away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer section still listed `+` among the controls deliberately left
out, on the grounds that the prompt reaches the agent as one string on stdin.
Both halves of that are now false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The palette was learned only as a side effect: Claude Code names every
command it can run on the init frame of a run, the server kept that list in
a Map, and the panel asked once. So the palette was empty before the first
message of a session, and empty again after every dev-server restart — which
an edit to vite.config.ts or anything it imports causes mid-session. The
panel then cached the empty answer and only asked again when the user typed
a fresh slash. That is the "sometimes it doesn't work" this fixes, and the
panel was doing exactly what it was written to do.

Claude Code now gets a probe of its own, the way codex already had one.
`claude -p /help` asks for that same init frame: /help is the command the
CLI answers by itself, so the result frame comes back num_turns 0,
duration_api_ms 0, total_cost_usd 0 — measured, not assumed. The answer is
thrown away; the frame printed above it is the point. --max-budget-usd is
the belt in case a release ever sends /help to the model instead.

Three things then have to hold for the palette to open on the slash itself.
The server asks the CLI when its map is empty, once per server. The panel
asks at mount, so a cold probe's few seconds are spent while the canvas is
being looked at rather than after a keystroke. And the browser remembers
what each agent last said, replaced only by a non-empty answer, so the first
paint has something even when the server is still asking.

No command name is written down anywhere. The list is whatever that install
of that CLI reports today — claude's off its own init frame, codex's off the
prompt it would have composed — so a new skill, a new plugin or a new
release shows up without a change here. The only literal is how to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It argued that claude is never asked because asking costs a turn, and cited
$0.017. That was right about the price of the probe it had in mind and wrong
about the conclusion: the list lived only in the server's memory, so the
palette was empty before the first message and after every restart. The note
now says that, says what /help costs instead, and says how the mount ask and
the remembered list keep the palette open on the slash itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board's frame says `color-scheme: light` on the element, and the
`:root{color-scheme:dark;color:#000}` injected after every board's doctype
is gone. The PR's measurement had tried the element-side value that matches
the canvas, not the one that matches the board; measured again in Chrome
153, `light` on the `<iframe>` composites transparent. Nothing is written
into a board's markup now, and an injected class-level rule no longer
outranks a board's own `html{}`. The design note says so.

Cmd+/ no longer flips tldraw to its light theme under a black rail: the
`toggle-dark-mode` action is deleted from the overrides, since the ground
remap, the welcome board and the panel tokens are all dark only.

Attachments are the four image types the CLIs read, on both sides of the
wire. `image/svg+xml` was accepted and served back on the dev server's
origin, where every /__sp endpoint writes, and the strip opens each tile as
a top-level document.

The /run body is collected as bytes and decoded once: a character split
across two chunks decoded to U+FFFD, and a CJK message or file name sits
after megabytes of base64. The cap is bytes too, and its message says the
same 24 MB the panel does. Per-run image folders live under one folder per
server named by its pid: removed with the server, and a folder left by a
server that was killed is swept at the next start.

In the panel: a send is held until the server answers, so a repeated Enter
no longer posts the message and its images twice; a fetch that rejects
outright surfaces as the send error instead of an unhandled rejection; the
tray checks the server's 20-image and size limits over the whole tray and
reports a file that could not be read; a box emptied by typing drops the
`<br>` that kept the placeholder away; Enter inside an IME composition is
the editor's; and the data URL the reader produced is kept whole rather
than rebuilt from megabytes of base64 on every keystroke.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hover a mockup or a piece of brand material and two buttons appear at its
top-right corner: + names the file in the composer, and the picture button
draws the board and drops it in the attachment tray. Brand material gets
only +, because it is already a picture.

+ writes `<slug>/<file>.html`, the name the server, the layout and the agent
all know a board by, not the module path its shape carries — canvasBoardRef
converts between them, with a test that the two really do differ. The
picture button goes through a new /__sp/shoot, which runs `refkit shoot` and
caches per board and artboard size.

Locked shapes get no hover from tldraw, so the overlay hit-tests the pointer
itself, sharing shapeUnderPointer with the inspector. Two rules that are not
obvious from the code: the corner is in viewport pixels, because the chat
panel takes the window's left edge and screen pixels would count that offset
twice; and reaching for the buttons is not a move to another shape, or the
bar follows the pointer onto the board underneath and steals the press.

Also here: refkit draws into its own directory and the finished file is
moved into place, so a second request during the seconds a render takes
cannot be served half a picture. The formatter reflowed canvasLibrary.test.ts
while it was open, which is most of that file's diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing + on a picture, or the picture button on a board, put the tile in
the tray and left the sentence to be written with a second click on it. A
picture pointed at on the canvas is one image and pointing at it is the
saying, so its #N chip is now written as it lands.

A pick, a paste or a drop still only fills the tray: those are a handful of
images at once, and which of them the message is about is still to be said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attach a picture, think better of it, remove it, attach another: it came
back as #2, with no #1 anywhere to tell it apart from. The counter ran for
the life of the composer, so a cleared-away false start still cost a number,
and so did every message sent.

Numbers still cannot be reused while something points at one — renumbering
under a sentence already typed would silently repoint it — so the reset asks
whether anything does: an empty tray and a box with no chip left in it,
struck through or not. That is checked wherever both can empty, which is
removing a tile, deleting the last chip by hand, sending, and starting over.

namesAPicture lives in chatDraft.ts beside readDraft, which is what makes a
struck-through chip count: it is drawn dead but still reads as "#1".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of the same rule — the tray and the message are one thing, not a
list beside a box.

A number belongs to a picture, not to the act of attaching one. Pressing +
on the same icon four times made four tiles of the same image and wrote #1
#2 #3 #4, which reads as four pictures that happen to look alike. The tray
is now searched for those exact bytes before a number is handed out, and the
chip is only written if the box does not already say it, so four presses
leave one tile and one #1. Bytes and not the file's name: a paste is called
image.png every time and is a different screenshot every time. That means
the reads finish before the numbers do; they still go out in the order the
files were picked, so three chosen at once are #1, #2, #3 down the dialog.

And deleting a chip now takes its picture out of the tray. Removing a tile
still only strikes its chips through — a sentence is not rewritten under
whoever typed it — but the other direction has no such excuse: a message
that no longer mentions a picture is not a message with that picture
attached. A picture the box has never named is left alone, since most are
attached before a word is typed; the panel compares each reading of the box
with the last to tell those apart.

The numbering restart now hangs off the tray itself rather than off each
caller, so a removal made from a stale render still counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resting the pointer on a picture's + and then scrolling with two fingers
froze the canvas until the pointer moved off again. Nothing was catching the
wheel: the attach bar renders in InFrontOfTheCanvas, which is a sibling of
.tl-canvas rather than a child of it, and tldraw listens for the wheel on the
canvas element alone — so a gesture that begins over the bar reaches no
handler at all.

usePassThroughWheelEvents hands it back, which is what every control tldraw
draws over its own canvas does: the toolbar, the minimap, a comment pin. It
steps aside for anything inside that really scrolls, so it costs nothing if
the bar ever grows a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hovering the clear part of a mockup drawn on nothing offered no + button,
and clicking it opened no inspector. tldraw hit-tests an image that can
carry transparency against its own alpha channel, so a phone mockup with
rounded corners answers for its screen and refuses its margins — which is
most of the tile for anything not rectangular.

It answers for all of it now, by way of a shape util that gives an image the
plain box its width and height describe. A circle crop is still a circle;
it is only the alpha channel that stops counting.

The store has to be handed the same set, and it refuses the same shape type
twice, so the default an override stands in for is dropped rather than
listed beside it.

Checked on x-ios: paidads-trend-takeover-mockup.png has four fully clear
corners, and before this all four went unanswered while the centre did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing + on a picture put a numbered tile above the box and a chip in the
sentence; pressing + on a mockup wrote a grey path badge and nothing else,
so the panel said which file had been named but never showed the thing that
had been pointed at.

A board now comes over the same way a picture does — drawn by the server, as
both buttons already had it drawn — and the name it arrives under is its own
`<slug>/<file>.html`. That is what the tile is captioned with, what the chip
says on hover, and what the agent is handed beside the picture's number, so
nothing is lost by dropping the path out of the sentence: `[Image #1]
x-ios/13-set-a-reminder.html` still names the file to go and open.

The two buttons keep their difference in that name alone: + hands over the
board, the picture frame hands over a drawing of it. The same board through
both is the same bytes, so it stays one tile with one number, which is the
rule the tray already had.

The tile is 76px, so its caption is the last segment; the whole path is the
tooltip and the alt text. The spinner follows whichever button was pressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picture frame drew the board and handed it over as `<file>.png`; + now
draws the same board and hands over the same bytes under `<file>.html`. Two
buttons for one action, and the tray deduped them into one tile anyway.

+ alone, then, and the spinner is back to a boolean: there is only one
button for it to be in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tray keeps the reader's data URL, and upstream's dedupe of a picture
pressed twice compares that instead of the sliced base64. Two things Codex
found on #98 are folded in: the limits count what is still being read, so
a second paste during the first one's read sees the whole tray, and the
tmp sweep removes a folder only when its pid is gone (ESRCH), not when it
belongs to another user (EPERM). And the tray is merged in a functional
update, so a pick that finishes reading second no longer overwrites the one
that finished first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Attaching runs one add at a time: two picks of one picture in flight together each numbered it,
and the second put a chip in the sentence for a tile the tray had dropped. The tray an add reads
is a ref written as it is set, so the add queued behind it sees it. The pending counters and the
updater's second dedup go with that, and the tray weighs its files by size rather than by
data-URL length. A drop or paste of a type the agents cannot read now says so instead of doing
nothing, and the four types and the two limits are said once, in agents.ts, for both sides.

Safari reports the Enter that confirms an IME candidate after composition has ended, with only
keyCode 229 to mark it; the guard reads that too. The placeholder keys off the draft state
through a data attribute rather than the box being wiped in onInput, which was throwing away
native undo and eating a leading Shift+Enter.

A run's image files go when its child closes or fails to spawn, and the image route serves the
bytes the run already holds. The server's close hook went: Vite restarts by building the new
server, same pid, before closing the old, so the hook was deleting the running server's folder;
Ctrl+C never fired it at all. The dead-pid sweep is the cleanup, and skips a folder it cannot
remove rather than failing the server on a shared tmp.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A path made of the pid alone is one anyone on a shared tmp could have put a folder or a link
at first, and the run's files would then have gone under theirs. mkdtemp makes it private and
atomically, and the sweep reads the pid off the name it makes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…server counts the bytes

The limits are over what a pick adds: the same picture pointed at again on a full tray kept its
number rather than being refused as one more, so the reads come first and the count and the
weight are of what is new. The server's body cap was the panel's limit in base64; a client that
is not the panel now meets the same limit in the bytes the files decode to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A read is the whole file in memory, a third larger, and a drop of a few hundred megabytes was
read in full to be refused after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four findings from the review on #97, all in the composer.

Two additions in the air at once each merged into the tray of the render that
made them — a drop landing while a paste was still being read — so the later
one won and the earlier picture vanished while its number had already been
handed out. Every change to the tray now goes through one setter that writes
a ref beside the state, and every decision reads the ref, so the second merge
sees what the first left. Not a state updater: the merge is what hands out
the numbers, and StrictMode runs an updater twice.

A command that is also the start of a longer one — `review` beside
`security-review` — still took two Enters, because the palette only closed
when its exact match was the only match. Any exact match closes it now.

The box stayed open while the message was away and was then emptied whole, so
a word typed in those milliseconds was swept out with a message it was never
part of. The box takes nothing until the server answers; the tray still does,
and a picture that lands meanwhile keeps its tile and number for the next
message. Tab still leaves the box, so a keyboard is never held in it.

And the size guard counted only the batch being added, so several drops could
each pass and the send fail with 413. It counts the tray too, in the server's
own numbers: twenty pictures, about 32 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ets its bytes

Four findings from the review on #97, all in the dev server.

A picture in the transcript is linked to open in a tab, and its type is
whatever the browser said when it was attached — so an SVG opened as a
document of this origin, where its script could POST /run and start an agent
in the project asked by no one. The canvas's attach button made that a click
away, since a board can carry any `.svg` dropped on it. Both picture routes
now answer with `content-security-policy: sandbox`, which opens them in an
opaque origin with no script, and `nosniff`. A PNG in a tab never notices,
and the same bytes drawn as an `<img>` are untouched.

A run kept every attachment's base64 after writing it to disk, and the newest
twenty runs are kept: it holds the numbers, types and paths now. The request
body is dropped as soon as it is parsed for the same reason — the listeners
the run leaves on its child close over that scope.

Attachment folders went under one folder per server process, swept at start
for the folders of processes that are gone. A close hook would not have done
it: `sp-canvas stop` is `tmux kill-session`, a SIGHUP Vite does not handle.
A folder whose pid is alive, or one this cannot signal, is left alone.

And `filed`, which had one call site, is inlined where CLAUDE.md says it goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three smaller findings from the review on #97.

Reading whether the chat panel was shut threw where site data is blocked, and
it was read during render, so a refused `localStorage` blanked the whole
canvas — in production, though the chat itself is dev-only. Both the read and
the write are guarded now, the way the snap default and the comment user
already were: storage refusing only means the choice lasts until a reload.

`shotsIn` had one call site and is inlined where CLAUDE.md says it goes.

And the shipped layout guide still told users to press a refresh button in
the top bar next to the actions menu. This branch moved force-relayout into
the canvas's right-click menu, so the guide named a button that is not there
for the one step that makes an edited `layout.json` take effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dark scheme this branch injects into every board was written `:root`,
and the inspector's agent takes the first `:root {…}` in the document's
stylesheets to be the board's token block. Ours goes in right after the
doctype, so it was always first: the Tokens tab and the token usage counts
came back empty for every board, since two declarations of ours is what the
inspector was reading instead of the board's fifty.

`html` names the same element at a lower specificity, so a board's own rules
still win — its `:root` by specificity, its `html` by order — and the first
`:root` in the sheet is the board's again. Nothing else here depends on the
spelling, and `canvasLibrary.ts` was already injecting with `html{…}`.

Checked with the real agent against x-ios/00-design-tokens.html: `:root`
reports no tokens in no groups, `html` reports 71 in seven. No committed
board sets `color` or `color-scheme` on an `html` rule of its own, so the
specificity drop repaints nothing.

Found by the review on #97.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layout guide was the one the review on #97 caught, but the same sentence
had been written four more times: three in the skill itself and one in the
repo README, each still telling a reader to press a button in the top bar for
the one step that makes an edited `layout.json` take effect.

The README also described a bottom toolbar with a styles-panel toggle. There
is no bottom toolbar — `canvasChrome.tsx` sets `Toolbar: null` — and the
styles panel is the inspector, which opens on the board you click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#98 forked at 0075875 and fixed the review of #97 in parallel with the five
commits on this branch. Neither knew about the other, so the three files at
the centre of the review conflicted. Resolved file by file, on which fix is
the better one rather than on which came first.

#98's, and this branch's dropped:

- The board backdrop. #98 deletes the injected `<style>` outright and sets
  `color-scheme: light` on the `<iframe>` element instead. This branch had
  renamed the injected selector `:root` → `html` to stop it shadowing the
  board's token block. Removing the injection dissolves that bug rather than
  working around it, and fixes the backdrop measurement besides, so the rename
  goes.
- The request body. Collected as `Buffer`s and decoded once: a character split
  across a chunk boundary decoded to U+FFFD, which this branch had not found.
- The attachment folder. `mkdtempSync` over this branch's `sp-chat-<pid>`,
  which was a predictable path on a shared tmp. Same dead-pid sweep, and #98's
  tells ESRCH from EPERM.
- Accepted types. png, jpeg, gif, webp, said once in `agents.ts` and read by
  both sides, so an SVG is refused before it is read rather than sandboxed on
  the way out. The sandbox headers stay on both image routes as the second
  layer, and the shot route now names its type from `IMAGE_TYPES` too.
- The composer. #98 serializes adds through a promise chain, which this
  branch's ref-and-setter did another way, and carries the IME, native undo
  and `data-empty` fixes besides.

Kept from this branch, which #98 does not have:

- The palette closes on any exact match, not only a sole one. #98 still had
  `found.length === 1 && found[0] === typing`, so `review` beside
  `security-review` took two Enters.
- The guarded `localStorage` read of the chat-panel state, `shotsIn` inlined,
  and the refresh-button instruction corrected in the README, the skill and
  the layout guide.

`tsc --noEmit`, `oxlint` and `vitest` (18 files, 141 tests) pass on the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
canvas: the panels move to Geist, and the canvas opens dark
@Jing-yilin
Jing-yilin merged commit f76bf21 into main Sep 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant